SPB Git forge
3commits 1branches 0releases
417.0 KBsize
maindefault branch
10 days agolast push
TypeScript 66.5% Python 30.9% JavaScript 1.4% CSS 0.7%
8.7 KB · 150 lines tsx
Raw Blame History
1import type { Metadata } from 'next';2import Link from 'next/link';3import { notFound } from 'next/navigation';4import { Donut, HBars, StackedBars } from '@/components/charts/charts';5import { Block, EventsList, ExternalLink, HeroFacts, KpiStrip, PlannedUnavailable, Tag, entityMetadata } from '@/components/entities/shared';6import { ConstellationsMiniTable, LaunchesTable, SatellitesTable, SitesTable } from '@/components/entities/tables';7import { Container } from '@/components/ui/section';8import { Unavailable } from '@/components/ui/unavailable';9import { ApiError, api } from '@/lib/api';10import { fmtDate, fmtDateTime, fmtInt, num, titleCase } from '@/lib/format';11import { MISSION_LABELS, ORBIT_CLASS_COLORS, SITE_URL, STATUS_COLORS, routes } from '@/lib/site';12import type { OperatorDetail } from '@/lib/types';1314type Props = { params: Promise<{ slug: string }> };1516async function load(slug: string): Promise<{ d: OperatorDetail; generatedAt: string } | null> {17  try {18    const res = await api.operator(slug);19    return { d: res.data, generatedAt: res.meta.generated_at };20  } catch (e) {21    if (e instanceof ApiError && e.notFound) return null;22    throw e;23  }24}2526export async function generateMetadata({ params }: Props): Promise<Metadata> {27  const { slug } = await params;28  const r = await load(slug).catch(() => null);29  if (!r) return { title: 'Operator not found', robots: { index: false } };30  const { d } = r;31  return entityMetadata({32    title: `${d.name} — ${fmtInt(d.active_payloads)} active satellites, ${titleCase(d.kind).toLowerCase()}${d.country_name ? ` (${d.country_name})` : ''}`,33    description: `${d.name} operates ${fmtInt(d.active_payloads)} active payloads out of ${fmtInt(d.total_payloads)} launched across ${fmtInt(d.launches)} launches since ${fmtDate(d.first_launch)}. Fleet by status, orbit and mission, constellations, launch history and growth.`,34    path: routes.operator(d.slug),35  });36}3738export default async function OperatorPage({ params }: Props) {39  const { slug } = await params;40  const r = await load(slug);41  if (!r) notFound();42  const { d, generatedAt } = r;4344  const statusData = d.status_distribution.map((s) => ({ label: titleCase(s.status.toLowerCase()), value: num(s.count) ?? 0, color: STATUS_COLORS[s.status] ?? 'var(--other)' })).filter((x) => x.value > 0).sort((a, b) => b.value - a.value);45  const orbitData = d.orbit_distribution.map((o) => ({ label: o.orbit_class, value: num(o.count) ?? 0, color: ORBIT_CLASS_COLORS[o.orbit_class] ?? 'var(--other)' })).filter((x) => x.value > 0).sort((a, b) => b.value - a.value);46  const missionData = d.mission_distribution.map((m) => ({ label: MISSION_LABELS[m.mission_type] ?? titleCase(m.mission_type), value: num(m.count) ?? 0 })).filter((x) => x.value > 0).sort((a, b) => b.value - a.value);47  const growth = d.growth.map((g) => {48    const launched = num(g.launched) ?? 0;49    const still = Math.min(launched, num(g.still_active) ?? 0);50    return { x: g.year, still_active: still, retired: Math.max(0, launched - still) };51  });5253  const jsonLd = {54    '@context': 'https://schema.org',55    '@type': 'Organization',56    name: d.name,57    alternateName: d.aliases,58    url: d.official_url ?? undefined,59    sameAs: d.official_url ? [d.official_url] : undefined,60    address: d.country_name ? { '@type': 'PostalAddress', addressCountry: d.country_code ?? d.country_name } : undefined,61    mainEntityOfPage: `${SITE_URL}${routes.operator(d.slug)}`,62  };6364  return (65    <Container wide>66      <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />6768      <header className="pb-6 pt-8 md:pb-8 md:pt-12">69        <nav aria-label="Breadcrumb" className="eyebrow">70          <Link href={routes.operators()} className="hover:text-ink">Operators</Link> <span aria-hidden>/</span> {d.name}71        </nav>72        <div className="mt-3 flex flex-wrap items-center gap-2">73          <Tag tone="accent">{titleCase(d.kind)}</Tag>74          {d.country_code && <Tag>{d.country_code}</Tag>}75        </div>76        <h1 className="display mt-3 text-3xl md:text-5xl">{d.name}</h1>77        <p className="mt-3 max-w-2xl text-[15px] text-ink-2 md:text-base">78          {d.country_slug ? <Link href={routes.country(d.country_slug)} className="link">{d.country_name}</Link> : 'Country unavailable'}79          {' '}· {fmtInt(d.active_payloads)} active payloads · {fmtInt(d.constellations)} {num(d.constellations) === 1 ? 'constellation' : 'constellations'}80        </p>81        {d.description && <p className="mt-4 max-w-3xl text-[15px] leading-relaxed text-ink-2">{d.description}</p>}82        <HeroFacts83          items={[84            { label: 'Official site', value: d.official_url ? <ExternalLink href={d.official_url} /> : null },85            { label: 'Also known as', value: d.aliases.length ? d.aliases.join(' · ') : null },86          ]}87        />88      </header>8990      <KpiStrip91        items={[92          { label: 'Active payloads', value: <span className="text-active">{fmtInt(d.active_payloads)}</span> },93          { label: 'On orbit', value: fmtInt(d.on_orbit_payloads) },94          { label: 'Total payloads', value: fmtInt(d.total_payloads) },95          { label: 'Decayed', value: fmtInt(d.decayed) },96          { label: 'Launches', value: fmtInt(d.launches) },97          { label: 'Launched 365 d', value: fmtInt(d.payloads_last_365d) },98          { label: 'Constellations', value: fmtInt(d.constellations) },99          { label: 'First launch', value: <span className="text-xl md:text-2xl">{fmtDate(d.first_launch)}</span> },100          { label: 'Last launch', value: <span className="text-xl md:text-2xl">{fmtDate(d.last_launch)}</span> },101          { label: 'Snapshot', value: <span className="text-base text-ink-2 md:text-lg">{fmtDateTime(generatedAt)}</span> },102        ]}103      />104105      <div className="grid gap-x-10 lg:grid-cols-[minmax(0,7fr)_minmax(0,4fr)]">106        <div className="min-w-0">107          <Block eyebrow="Growth" title="Fleet growth by launch year" id="growth">108            {growth.length ? (109              <StackedBars data={growth} keys={['still_active', 'retired']} labels={{ still_active: 'Launched · still active', retired: 'Launched · no longer active' }} title="Payloads launched per year, split by current status" height={200} />110            ) : (111              <Unavailable what="Fleet growth" />112            )}113          </Block>114          <Block eyebrow="Programmes" title={`Constellations · ${fmtInt(d.constellations)}`} id="constellations">115            <ConstellationsMiniTable rows={d.constellations_list} />116          </Block>117          <Block eyebrow="Launches" title={`Launch history · ${fmtInt(d.launches)} launches`} id="launches">118            <LaunchesTable rows={d.launches_list} />119            {d.launches_list.length < (num(d.launches) ?? 0) && <p className="mt-2 text-xs text-ink-3">Showing the {fmtInt(d.launches_list.length)} most recent launches.</p>}120          </Block>121          <Block eyebrow="Fleet" title="Fleet sample" id="fleet" action={{ href: routes.satellites(`operator=${encodeURIComponent(d.slug)}`), label: 'All satellites operated' }}>122            <SatellitesTable rows={d.fleet_sample} columns={['orbit', 'mission', 'perigee']} />123          </Block>124          <Block eyebrow="Timeline" title="Events" id="events" action={{ href: routes.events(`entity=${encodeURIComponent(d.id)}`), label: 'All events' }}>125            <EventsList events={d.events} />126          </Block>127        </div>128129        <aside className="min-w-0 lg:border-l lg:border-rule lg:pl-10">130          <Block eyebrow="Fleet" title="By status">131            <Donut data={statusData} title="Payloads by status" total={num(d.total_payloads) ?? undefined} size={140} />132          </Block>133          <Block eyebrow="Fleet" title={<span className="inline-flex items-center gap-2">By orbit class <Link href={routes.methodology()} className="text-[10px] font-semibold uppercase tracking-[0.12em] text-accent-2 hover:underline">derived</Link></span>}>134            <Donut data={orbitData} title="Payloads on orbit by orbit class" size={140} />135          </Block>136          <Block eyebrow="Fleet" title={<span className="inline-flex items-center gap-2">By mission <Link href={routes.methodology()} className="text-[10px] font-semibold uppercase tracking-[0.12em] text-accent-2 hover:underline">derived</Link></span>}>137            <HBars data={missionData} />138          </Block>139          <Block eyebrow="Ground" title="Launch sites">140            <SitesTable rows={d.launch_sites} />141          </Block>142          <Block eyebrow="Monitoring" title="Recent announcements">143            <PlannedUnavailable what="Recent announcements" note="Company monitoring (press releases, filings, official channels) is planned. Nothing is shown until a source is connected." />144          </Block>145        </aside>146      </div>147    </Container>148  );149}150